SPB Git

Repository Public

route.ts 4.5 KB 120 lines typescript

1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// /api/admin/submissions/[id] — workflow d'une demande depuis le panel.3// PATCH { status | priority | confidentiality | assigned_to | tags | is_read4//         | is_spam | is_archived } — chaque changement est journalisé dans5// l'historique d'activité. Contrôle d'accès PAR demande (anti-IDOR) : l'ID6// vient de l'URL mais le droit est revérifié serveur à chaque appel.7import { NextRequest, NextResponse } from "next/server";8import {9  findSubmissionById,10  updateSubmission,11  markResolved,12  logEvent,13  findAdminById,14} from "@/lib/comms/db";15import {16  getAdminSession,17  canViewSubmission,18  isSuperAdmin,19  CATEGORY_ROLE,20} from "@/lib/comms/admin-auth";21import {22  statusesFor,23  TERMINAL_STATUSES,24  PRIORITIES,25  CONFIDENTIALITY_LEVELS,26} from "@/lib/comms/categories";2728export async function PATCH(29  req: NextRequest,30  { params }: { params: Promise<{ id: string }> },31) {32  const session = await getAdminSession();33  if (!session)34    return NextResponse.json({ error: "Non autorisé." }, { status: 401 });35  const id = Number((await params).id);36  const sub = Number.isInteger(id) ? findSubmissionById(id) : undefined;37  if (!sub || !canViewSubmission(session, sub))38    // 404 (et non 403) : ne pas révéler l'existence d'une demande interdite.39    return NextResponse.json({ error: "Demande introuvable." }, { status: 404 });4041  const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;42  if (!body)43    return NextResponse.json({ error: "Requête illisible." }, { status: 400 });4445  const patch: Record<string, unknown> = {};46  const events: [string, string][] = [];4748  if (typeof body.status === "string") {49    const allowed = statusesFor(sub.category);50    if (!allowed[body.status])51      return NextResponse.json({ error: "Statut invalide." }, { status: 422 });52    patch.status = body.status;53    events.push(["statut", `${allowed[sub.status] ?? sub.status} → ${allowed[body.status]}`]);54  }55  if (typeof body.priority === "string") {56    if (!(body.priority in PRIORITIES))57      return NextResponse.json({ error: "Priorité invalide." }, { status: 422 });58    patch.priority = body.priority;59    events.push(["priorite", `${sub.priority} → ${body.priority}`]);60  }61  if (typeof body.confidentiality === "string") {62    if (!(body.confidentiality in CONFIDENTIALITY_LEVELS))63      return NextResponse.json({ error: "Niveau invalide." }, { status: 422 });64    // Relever/abaisser la confidentialité d'une catégorie sensible :65    // super_admin ou rôle dédié seulement.66    const dedicated = CATEGORY_ROLE[sub.category];67    if (68      body.confidentiality === "restreinte" &&69      !isSuperAdmin(session) &&70      !(dedicated && session.roles.includes(dedicated))71    )72      return NextResponse.json(73        { error: "Seul le rôle dédié peut restreindre cette demande." },74        { status: 403 },75      );76    patch.confidentiality = body.confidentiality;77    events.push(["confidentialite", `${sub.confidentiality} → ${body.confidentiality}`]);78  }79  if (body.assigned_to !== undefined) {80    if (body.assigned_to === null) {81      patch.assigned_to = null;82      events.push(["assignation", "Demande désassignée"]);83    } else {84      const target = findAdminById(Number(body.assigned_to));85      if (!target || !target.active)86        return NextResponse.json({ error: "Admin inconnu." }, { status: 422 });87      patch.assigned_to = target.id;88      events.push(["assignation", `Assignée à ${target.display_name}`]);89    }90  }91  if (Array.isArray(body.tags)) {92    const tags = body.tags93      .filter((t): t is string => typeof t === "string")94      .map((t) => t.trim().slice(0, 40))95      .filter(Boolean)96      .slice(0, 12);97    patch.tags = JSON.stringify(tags);98    events.push(["tags", tags.join(", ") || "(aucun)"]);99  }100  for (const flag of ["is_read", "is_spam", "is_archived"] as const) {101    if (typeof body[flag] === "boolean") {102      patch[flag] = body[flag] ? 1 : 0;103      if (flag !== "is_read")104        events.push([105          flag === "is_spam" ? "spam" : "archive",106          body[flag] ? "activé" : "retiré",107        ]);108    }109  }110111  if (!Object.keys(patch).length)112    return NextResponse.json({ error: "Rien à modifier." }, { status: 400 });113114  updateSubmission(sub.id, patch);115  if (typeof patch.status === "string")116    markResolved(sub.id, TERMINAL_STATUSES.has(patch.status));117  for (const [kind, detail] of events) logEvent(sub.id, session.id, kind, detail);118  return NextResponse.json({ ok: true });119}120
Files main
Menu

Theme